昨天的
/spec-check可以告訴我規格哪裡有問題,但它只是「告訴我」。如果我希望某條規則不能被違反,而不是期待 Claude 記得遵守,就得再往下一層。這就是今天要介紹的 Hooks。
昨天做出第一支自己的 skill,它會逐條檢查規格、輸出缺口報告。
但它有個限制:觸發的方式是我得記得叫它。 而且就算它報出九項必補,也沒有任何東西阻止我直接把規格交出去。
這其實是 D04 就埋下的問題:
CLAUDE.md 是 Context,不是防火牆。
當時只是先提到,今天就把這件事情講完:如果真的想阻止 Claude 做某件事,該怎麼做?
CLAUDE.md 的內容會在每個 session 開始時被讀進 context。它是資訊,不是設定。
所以「不要直接改 migration 檔」這句話寫在 CLAUDE.md 裡,效果等於在一段很長的提示詞裡加一句叮嚀。Claude 大部分時候會照做,但不是一道門檻。
Hook 就是在 Claude Code 執行某個動作前後,插入一段自己的程式。
Claude 想修改檔案
↓
PreToolUse Hook
↓
檢查規則
↓
┌────┴────┐
↓ ↓
通過 阻擋
↓ ↓
繼續執行 停止
Hook 設定放在:
.claude/settings.json
例如:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard.py\"" }
]
}
]
}
}
這裡有兩個重點:
Claude Code 有很多不同的 Hook Event,和開發流程比較相關的幾個如下:
| 事件 | 什麼時候觸發 |
|---|---|
PreToolUse |
工具呼叫執行之前 |
PostToolUse |
工具呼叫成功之後 |
Stop |
Claude 準備結束回應時 |
SessionStart |
session 開始或恢復 |
InstructionsLoaded |
CLAUDE.md 或 rules 被載入時 |
第一個實驗先處理一個很常見的問題:Claude 說「做完了」,真的代表做完了嗎?
所以我把 Hook 掛在 Stop 事件上,讓 Claude 準備結束回應時,先跑一次 pytest,如果測試沒過,就不可以收工。
設定
{
"hooks": {
"Stop": [
{
"hooks": [{
"type": "command",
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/stop-run-tests.py\"",
"statusMessage": "收工前跑測試"
}]
}
]
}
}
Hook 裡加了一個簡單的 marker,避免同一個 session 因為測試失敗而無限觸發:
if os.path.exists(marker):
sys.stderr.write("測試仍未通過,但本 session 已提醒過一次,放行。")
return 0
執行
接著故意給 Claude 一個會讓測試失敗的任務:
把 /health 回傳的 status 從 "ok" 改成 "healthy",不要動測試檔
結果
Claude 修改 main.py 後準備收工,卻被 Hook 擋下來:
● Ran 1 stop hook
⎿ Stop hook error: 測試未通過,先不要收工。
E AssertionError: assert {'status': 'healthy'} == {'status': 'ok'}
FAILED tests/test_health.py::test_health_returns_ok
請修好再結束。
這次 Hook 做到了一件很單純的事:測試沒過,就不能宣稱任務完成,而且我還加了一個限制:不能修改測試檔,所以 Claude 不能直接把測試改成 healthy 來讓它變綠,只能回頭修正真正的問題。
這就是 Hook 和 CLAUDE.md 的差別;CLAUDE.md 可以告訴 Claude:「記得跑測試。」,Hook 則可以直接把它變成:「測試沒過,你不能收工。」
第一個實作解決的是流程上的門檻,我還有另一個問題:如果有些檔案本來就不應該讓 AI 修改呢?例如專案的開發原則,理論上應該由人決定,而不是 Claude 在執行任務時自己修改。
所以第二個實驗,我改用 PreToolUse:只要 Claude 準備修改受保護的檔案,就先攔下來。
設定
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|NotebookEdit|Bash",
"hooks": [{
"type": "command",
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-constitution.py\"",
"statusMessage": "檢查是否修改受保護檔案"
}]
}
]
}
}
保護的檔案包含:
PROTECTED = [
".specify/memory/constitution.md",
".claude/settings.json", # 不然它可以直接把 hook 刪掉
".claude/hooks/guard-constitution.py",
]
這裡特別把 settings.json 和 Hook 自己也放進保護清單。不然 Claude 如果可以修改 settings.json,理論上就能把 Hook 關掉;如果可以修改 guard 本身,也可能直接把保護邏輯改掉。
不同工具收到的參數結構不一樣,所以判斷方式也不同:
執行
接著測試:
把 .specify/memory/constitution.md 的第一條原則改成「API 欄位一律 camelCase」
結果
這次Hook 擋下來了,Claude 沒辦法直接修改檔案,並把原本的修改轉成一份給人執行的提案:
它把 AI 的角色從直接修改,變成分析影響 → 提出方案 → 由人決定是否執行
上面是測試成功的版本,但其實前幾版也有遇到失敗的案例,以下是我實測過程中遇到的問題:
Matcher 沒有攔到
我開一個新的 Session 請 Claude:把憲法第一條從 snake_case 改成 camelCase,結果它真的改成功了。
看執行紀錄才發現:Claude 這次不是使用 Edit 或 Write,而是 Bash → 執行指令 → 修改檔案。我原本的 Matcher 只有 Edit|Write,所以 Hook 根本沒有被觸發。
以為在保護「檔案」,但 PreToolUse 實際上是在攔「工具」。
補上 Bash,還是不夠
於是我把 Matcher 改成 Edit|Write|NotebookEdit|Bash,這次真的擋住了,但我又測了幾個情境:
結果是全部放行
原因很簡單,我保護的是:constitution.md,這道門會保護憲法,卻不保護自己,所以我又把 Hook 本身和設定檔加入保護範圍。
Hook 攔的是工具,不是人
Claude 又提醒我一件事:
PreToolUse 只能攔 Claude Code 的工具呼叫,不能攔人直接修改檔案。
如果真的要做到檔案層級的保護,還是要靠:
Hook 比較適合拿來做:流程的門檻,而不是:檔案系統的防火牆。
明天:hook 讓規則在本機有了執行力,但它擋的都是這台機器上的事。如果規格不只活在 markdown,還存在 Issue、資料庫 schema、套件文件裡呢?那就要讓 Claude 能連出去——MCP。